在前面章節中我們解析了將 effect 設計成即使多次執行也能保持正確的重要性。如果你還對這個觀念不是很熟悉的話,非常建議你先閱讀系列文前面的篇幅中關於 useEffect
的深度解析。而在這個 useEffect
主題的最後一個篇章中,我們將介紹以及分享一些常見情境的 effects & cleanups 設計技巧,幫助大家在實戰中能夠更能得心應手的應對。
Effects 的理想設計方向是宣告式且造成的影響為可逆的。這邊我們可以先大致介紹一些常見的 effects 設計問題:
一般來說這些問題的解決方案就是實作 effect 的 cleanup function。cleanups 應該要負責停止或逆轉 effects 中造成的影響,以保證你的 effects 即使在多次執行的情況下也能正常運作,並且不會造成 memory leak 的問題。
呼叫 fetch 來請求一個後端的 API 或許是實戰中最常遇到的 effect:
useEffect(
() => {
async function startFetching() {
const json = await fetchTodos(userId);
setTodos(json);
}
startFetching();
},
[userId]
);
這個 effect 的 dependencies 是誠實的,當 userId
在 re-render 時有所改變時,可以正確的重新再執行一次 effect。不過由於 fetchTodos
的動作是非同步的,因此當這個 effect 連續被執行時,先執行的 effect 的 fetch 結果並不一定比後執行的 effect 的 fetch 要更早返回,就會造成 race condition 的問題。以下舉例可能發生的狀況流程:
// 第一次 render 時,userId 為 1,對應的 effect 被執行時發起 fetchTodos
fetchTodos(1);
// 第二次 render 時,userId 為 2,由於 userId 與上次 render 時不同
// 因此會順利觸發對應的 effect,發起另一次 fetchTodos
fetchTodos(2);
//此時過了一段時間之後,fetchTodos(2) 的非同步事件率先完成並返回結果
setTodos([ /* ...userId 為 2 的 todos 內容 */ ]);
//然後 fetchTodos(1) 的非同步事件比較晚才完成並返回結果
setTodos([ /* ...userId 為 1 的 todos 內容 */ ]);
因此,雖然我們的 effect 有正確的在 userId
改變時再次處理同步,但是最後 todos state 中留下的資料結果卻有可能反而是比較舊的請求結果。
要處理這種 fetch 的 race condition 的問題,通常能以 abort fetch 或忽略舊的 request 結果來解決。這邊我們介紹以一個簡單的 flag 就能解決的方法:
useEffect(
() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
},
[userId]
);
這個解法的原理非常簡單,就是讓每次 render 的 effect 本身都記得自己是否應該忽略 fetch 結果的 flag。在每次 render 的 effect 中,這個 flag 變數 ignore
的值預設會是 false
,所以每次 effect 觸發的一開始時都會是正常處理的狀態。但是一旦這個 effect 在新的 render 中再次被執行前,就會先執行前一次 effect 對應的 cleanup,所以此時就會將前一次 effect 中的 ignore
改成 true
。這樣處理後,即使比較前面的 fetch 更晚才返回結果,也會因為 ignore
被改成了 true
而不會進行 setTodos
的動作:
// 首次 render 時,userId 為 1,對應的 effect 被執行時發起 fetchTodos
ignore(首次 render 的 effect 裡的)= false;
fetchTodos(1);
// 第二次 render 時,userId 為 2,由於 userId 與上次 render 時不同
// 此時會先執行前一次 effect 的 cleanup
// 然後才會執行本次 render 的 effect,發起另一次 fetchTodos
ignore(首次 render 的 effect 裡的)= true;
ignore(第二次 render 的 effect 裡的)= false;
fetchTodos(2);
// 此時過了一段時間之後,fetchTodos(2) 的非同步事件率先完成並返回結果
setTodos([ /* ...userId 為 2 的 todos 內容 */ ]);
// 然後 fetchTodos(1) 的非同步事件比較晚才完成並返回結果
// 但是由於首次 render 的 effect 中的 ignore 已經被改成 true,
// 所以不會執行 setTodos 的動作
此外,這個修改 flag 的 cleanup 也會在 unmount 時執行,而這樣就能夠很好的避免 fetch 在 component 已經 unmount 後才返回結果並嘗試 setTodos
而造成的 memory leak 問題。
不過關於請求 API 的情境需求,在實務上最推薦的解決方案其實還是使用主流的第三方套件。這些熱門的第三方套件通常都已經幫我們內建處理好以上的這種 race condition 問題,甚至還內建了快取機制、效能調校等實用的功能:
有時候我們會在 React 專案中使用一些非 React 基礎的外部套件,例如說與第三方的 map API 做串接:
useEffect(
() => {
if (!mapRef.current) {
mapRef.current = new FooMap();
}
},
// 這裡是因為沒有任何依賴才填空陣列,
// 而不是為了控制 effect 只執行一次
[]
);
上面這個 effect 即使多次執行也是沒問題的,因為當 mapRef.current
有東西時就不會重新執行到 new FooMap()
的處理。不過更理想的做法是把 initialize 的流程搬到 React App 的頂層 component 中,或甚至是 React 之外,以確保它在整個 App 只會執行一次,而不是每個 React component 的生命週期中都各自產生 FooMap
的 instance。因此,我們通常會建議在整個專案中盡可能的減少重複的第三方套件的初始化動作,甚至是只初始化一次就好。
而下面這個範例中的 effect 則是真的在做同步的動作,因此多次重複執行是沒有問題的:
useEffect(
() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
},
[zoomLevel]
);
如果 zoomLevel
變化的太頻繁連帶導致 re-render 的效能問題的話,也可以考慮再另外加上 throttle 等調校處理。
下面這個範例中,則是控制了某些外部套件的動作,如果這個動作是不能被覆蓋的話,則你應該在 cleanup 中去執行一些可以逆轉 effect 影響的操作:
useEffect(
() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
},
[]
);
訂閱 DOM 或是各種自定義的事件也是常見的一種 component effect:
useEffect(
() => {
window.addEventListener('scroll', (e) => {
console.log(e.clientX, e.clientY);
});
// ❌ 這裡應該要實作對應的 cleanup 來取消事件訂閱
},
[]
);
如果我們沒有在 cleanups 中處理對應的取消訂閱動作,那這個訂閱就會在 component 已經 unmount 後仍持續運作,而造成 memory leak。
useEffect(
() => {
function handleScroll(e) {
console.log(e.clientX, e.clientY);
}
window.addEventListener('scroll', handleScroll);
// ✅ 在 cleanup 中處理事件的取消訂閱
return () => {
window.removeEventListener('scroll', handleScroll)
};
},
[]
);
setTimeout
與 setInterval
也是同理,如果沒有在 cleanups 中處理取消註冊的動作的話,都有可能會在 component unmount 還嘗試執行 callback,而造成 memory leak 的問題。
function Counter() {
const [count, setCount] = useState(0);
useEffect(
() => {
const id = setInterval(() => {
setCount(prevCount => prevCount + 1);
}, 1000);
// ✅ 在 cleanup 中處理 interval 的取消註冊
return () => clearInterval(id);
},
[]
);
// ...
}
有時候即使你寫 cleanup 也沒有辦法清除 effect 造成的影響,例如在 effect 中去打 API 告訴後端你要結帳購買一個產品。這個 effect 影響的層面擴及到伺服器端甚至是資料庫,因此你無法透過 cleanup 去逆轉這個影響。這種情況的真正問題是我們不應該把某些對應「使用者行為意志」的動作放在 effect 令其隨著 render 而被自動多次執行:
useEffect(
() => {
// ❌ 這個 request 會在 React 18 的 strict mode + dev env 自動被送出兩次
// 它應該被寫在使用者觸發的事件中,而不是隨著 render 自動執行的 effect 中
fetch('/api/buy', { method: 'POST' });
},
[]
);
我們應該把它放在使用者自己觸發的事件中,例如使用者點擊了一個「送出購買」的按鈕:
function handleClick() {
// ✅ 結帳購買的動作將會由使用者進行操作後才對應觸發一次
fetch('/api/buy', { method: 'POST' });
}
useEffect
的總結整理在過去好幾篇文章中,我們以大量的篇幅來從各種角度全面的解析了 useEffect
的核心設計概念以及正確的使用方式,到這邊也算是告一個段落了。最後我們也在此整理一下其中的重點精華觀念:
useEffect
的正確用途
useEffect
用於「從資料同步到 effect 的行為與影響」useEffect
讓你根據目前的資料來同步到 React elements 以外的事物或副作用useEffect
是隨著每次 render 後而自動觸發的
useEffect
的 dependencies 是一種「忽略某些不必要的同步」的效能最佳化,而不是用來控制 effect 發生在特定的 component 生命週期,或特定的商業邏輯時機useEffect
應設計成即使多次重複執行也有保持行為正確的彈性
useEffect
無論隨著 render 重新執行了幾次,你的程式結果都應該保持同步且正常運作在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~
《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》
目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:
天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695
博客來(平裝版):
https://www.books.com.tw/products/0010982322
momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845
安安Zet大,
文章中好像race condition
有拼錯的地方,再麻煩看一下是不是筆誤~
眼睛真利XD 已修正感謝提醒~